Skip to content

Refactor: host build graph, eager completion watermark, 4 AICPU schedulers - #1618

Open
raphael-s-steiner wants to merge 1 commit into
hw-native-sys:mainfrom
huawei-csl:refactor/eager-completion-mark
Open

Refactor: host build graph, eager completion watermark, 4 AICPU schedulers#1618
raphael-s-steiner wants to merge 1 commit into
hw-native-sys:mainfrom
huawei-csl:refactor/eager-completion-mark

Conversation

@raphael-s-steiner

@raphael-s-steiner raphael-s-steiner commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Refactor: host build graph, eager completion watermark, 4 AICPU schedulers

Base: main · Branch: refactor/eager-completion-mark · 1 commit, 15 files (+651/-466)

One of two possible versions of host-build-graph completion-watermark refactor the other one being #1619.

This eager one is more performant due to minimal work, but has delicate synchronization logic.

Summary

Reverts the 3S+1P scheduler split back to 4 uniform AICPU schedulers, and reworks
completed_watermark maintenance to be eager rather than lazy. Eager updates require
completion_flags to become a reusable, thread-safe structure (previously a single
byte per slot, implicitly host-only), which in turn needed a failsafe against the
flag-slot-reuse deadlock the old design couldn't hit.

1. Revert: 3 schedulers + 1 dedicated resolution thread → 4 AICPU schedulers

The earlier design split AICPU threads into 3 core-owning scheduler (S) threads plus
1 core-less resolution (P) thread that alone drained completions, published
completion_flags, drained wake lists, and advanced the watermark — funneling all
completion resolution through a single thread.

This PR removes that split entirely:

  • CompletedTaskQueue (the per-S → P SPSC handoff ring) and run_resolution_thread
    are deleted (scheduler_context.h, scheduler_dispatch.cpp).
  • p_thread_idx() / p_thread_idx_ are gone; assign_cores_to_threads no longer
    reserves the last thread as core-less, and the aicpu_thread_num >= 2 floor (1 S +
    1 P) is dropped — active_sched_threads_ = aicpu_thread_num_ again, so all 4
    threads own cores and resolve their own completions.
  • aicpu_executor.cpp calls resolve_and_dispatch uniformly instead of branching on
    whether a thread is the P thread.
  • Async mailbox polling and dummy/predicate-failed-task retirement (previously P's
    job) move back into each scheduler thread's own resolve_and_dispatch loop.

2. Completion watermark: eager updates

completed_watermark is now advanced eagerly by every completer, not just
opportunistically:

  • Every completion path — device (on_mixed_task_complete), its deferred retry
    (retry_set_completion_flags), and the host orchestrator's inline hidden-alloc
    completion — calls update_completed_watermark(thread_idx, my_id) exactly once,
    immediately after that id's own completion_flags entry is actually visible.
  • The call is a no-op unless my_id is exactly the current watermark frontier; only
    the completer landing at the frontier does the CAS-advance walk over the full
    contiguous completed prefix. Out-of-order completers defer to whoever completes the
    frontier task later.
  • Per-thread cached_completed_watermark avoids re-reading the atomic on every
    is_completion_flag_set check by falling back to a cached watermark value.
  • Semantics flip slightly: completed_watermark is now "lowest id not yet
    guaranteed complete" (was "highest id guaranteed complete"), so comparisons flip
    from >= to > at call sites (wait_for_tensor_ready, reclaim gates, etc.).

3. completion_flags: fewer flags than tasks, slots reused

completion_flags changes from a uint8_t[task_window_size] byte array (host-only
writer, implicitly one-shot) to an int32_t[task_window_size] array where each entry
stores either -1 (pending) or the local_id that owns it. Because the stored value
is the id itself rather than a boolean, a slot can be safely reused across laps:
the array no longer needs to be sized to the total task count, only to the ring's
task window — local_id and local_id + task_window_size share a slot, and reuse is
gated on completed_watermark having certified the slot's previous occupant first.

  • flag_index() bit-reindexes local_id & task_window_mask (swaps low
    shuffle_lower_bits bits into the high position) so consecutive ids land on
    different cachelines, keeping update_completed_watermark's linear scan
    cache-friendly.
  • set_completion_flag (host, blocking) and the new try_set_completion_flag
    (device, non-blocking) both gate the store on the previous occupant being
    certified; try_set_completion_flag returns false instead of spinning when it
    isn't.
  • is_completion_flag_set falls back to completed_watermark so a slot that's been
    overwritten by a later lap still reports the earlier id as complete.

4. Deadlock failsafe for flag-slot reuse

The correctness argument for reuse is: task t must not depend on a task with id
>= t + task_window_size, which holds automatically since task ids follow the
dependency graph's topological order. But rather than assume that invariant always
holds, a failsafe absorbs a violation instead of deadlocking:

  • When try_set_completion_flag fails inside on_mixed_task_complete, the task id is
    pushed onto a per-thread min-heap (failed_heap_of_set_completion_flag) instead of
    the thread spinning or blocking. Wake-list drain and the watermark update for that
    id are skipped and deferred.
  • Each dispatch-loop iteration calls retry_set_completion_flags, which retries the
    smallest pending id in the heap; on success it drains that task's wake list and
    advances the watermark — its one deferred chance, taken later instead of never.
  • Expected steady state: the heap is empty and drains on the very next retry when it
    isn't — it exists purely as a backstop, not a normal-path mechanism.

Also in this diff

  • docs/RUNTIME_LOGIC.md (§6.2, §7.2, §8.2, §8.4) rewritten to match the above.
  • New unit test test_hbg_shared_memory.cpp covers the shuffle_higher_bits
    invariant flag_index() depends on (rejects a task_window_size too small to
    provide shuffle_lower_bits of headroom, which would otherwise be a negative
    shift / UB).
  • PTO2SharedMemoryRingHeader grows from 256 → 576 bytes and
    PTO2SharedMemoryHeader from 320 → 640 bytes (new cached_completed_watermark
    array + wider completion_flags entries); layout static_asserts updated
    accordingly.

Performance

Comparison against main and #1619 for both device wall-clock and kernel only (as measured by tracr)

A226CE25-BED4-4002-B56C-8E6CE81FA767 5F71AA5E-3B03-40EF-80BE-714FE8597F7E 6C3BA7A0-E4F9-40B5-B67D-EACF477455DE

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c3b65ca-83d4-4f19-85b5-9d6dbfa88263

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR removes the dedicated resolution thread from the AICPU scheduler, unifying all threads under a single scheduling model. Completion flags change from byte-based to int32 identity stamps with stricter watermark semantics, requiring updates across shared memory, orchestrator, runtime, and scheduler code, plus a per-thread retry mechanism, documentation, and a new test.

Changes

Scheduler unification and completion-flag rework

Layer / File(s) Summary
Remove dedicated resolution thread; unify scheduler threads
.../aicpu/aicpu_executor.cpp, .../scheduler/scheduler_context.h, .../scheduler/scheduler_cold_path.cpp, .../scheduler/scheduler_dispatch.cpp, .../scheduler/scheduler_completion.cpp
All AICPU threads now invoke resolve_and_dispatch. The dedicated resolution thread, its CompletedTaskQueue, run_resolution_thread, and p_thread_idx() are removed. Async completion polling and dummy-task drain run inline on every scheduler thread, and consumer resolution completes directly through on_task_complete.
Identity-based completion flags and watermark storage
.../runtime/pto_shared_memory.h, .../runtime/shared/pto_shared_memory.cpp, .../runtime/pto_async_wait.h, .../runtime/pto_runtime2_types.h
Completion flags change from atomic uint8_t bytes to atomic int32_t identity stamps with shuffled indexing and cached per-thread watermark values. try_set_completion_flag is added for non-blocking publication. init_header now validates task-window sizes and returns bool, initializing flags to -1 and watermark to 0. Slot reclamation now requires the watermark to strictly exceed the consumer ID.
Orchestrator and runtime watermark call sites
.../orchestrator_core/pto_orchestrator.cpp, .../orchestrator_core/pto_runtime2.cpp
Comments and initialization constants reflect the -1 seed and strict-greater watermark condition. Host-originated pre-completed tasks in alloc_tensors explicitly set flags and advance the watermark. Consumer-readiness polling uses an inclusive watermark comparison.
Per-thread completion-flag retry heap and thread-aware fanin
.../scheduler/pto_scheduler.h, .../shared/pto_runtime2_init.cpp
A per-thread min-heap retries failed completion-flag publications. fanin_satisfied, classify_fanin_state, register_wake, drain_wake_list, and on_task_complete all become thread-index aware, and retry_set_completion_flags is added along with heap cleanup during scheduler destruction.
Runtime logic documentation updates
.../docs/RUNTIME_LOGIC.md
Documentation describes identity-based completion flags, host inline completion, deferred flag-slot reuse, and the exclusive watermark frontier.
Shared-memory unit test
tests/ut/cpp/CMakeLists.txt, tests/ut/cpp/a2a3/test_hbg_shared_memory.cpp
A new test target and test file verify the shuffle_higher_bits invariant, including rejection of task window sizes below the shuffle floor.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SchedulerThread
  participant CompletionFlags
  participant WakeList
  participant Watermark

  SchedulerThread->>CompletionFlags: try_set_completion_flag(thread_idx, local_id)
  alt flag set successfully
    CompletionFlags-->>SchedulerThread: success
    SchedulerThread->>WakeList: drain_wake_list(thread_idx)
    SchedulerThread->>Watermark: update_completed_watermark(thread_idx, local_id)
  else reuse not yet certified
    CompletionFlags-->>SchedulerThread: failure
    SchedulerThread->>SchedulerThread: push to failed_heap_of_set_completion_flag
    SchedulerThread->>SchedulerThread: retry_set_completion_flags(thread_idx) later
  end
Loading

Possibly related PRs

  • hw-native-sys/simpler#1619: Implements the same architectural refactoring restoring N full AICPU schedulers, replacing byte completion flags with int32 stamps, and adding per-thread retry heaps across the identical files.
  • hw-native-sys/simpler#1536: Introduces the completion-flag helper APIs and shared-memory layout that this PR extends with thread_idx parameters and int32_t atomics.
  • hw-native-sys/simpler#1544: Introduced the dedicated resolution-thread architecture that this PR reverses by removing run_resolution_thread and p_thread_idx.

Poem

A rabbit hops through threads all night,
No more one runner holds the light.
Every burrow now can flag and mark,
Watermarks rise past the dark.
Stamps of int32, IDs so true,
Hop, hop, hooray — the scheduling's new! 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the scheduler refactor, eager completion watermark, and four-AICPU-scheduler design.
Description check ✅ Passed The description directly explains the scheduler refactor, completion-watermark redesign, synchronization safeguards, tests, and performance results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/a2a3/runtime/host_build_graph/runtime/scheduler/scheduler_cold_path.cpp (1)

699-712: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Lower the assigned thread count instead of returning 0 for the all-active case.

nthreads == 1 becomes aicpu_thread_num_ = 1, which makes assign_cores_to_threads() return false because scheduler threads are configured to be fewer than nthreads. Also avoid assigning aicpu_thread_num_ == MAX_AICPU_THREADS: assign_cores_to_threads() then loops over all core_trackers_/array entries while aic_count_ == 0, so aic_count_ / active_sched_threads_ yields 0 and no cores are registered (same as returning 0 early with aicpu_thread_num_ = 2).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/a2a3/runtime/host_build_graph/runtime/scheduler/scheduler_cold_path.cpp`
around lines 699 - 712, Update the scheduler-thread count calculation that feeds
SchedulerContext::assign_cores_to_threads() so the all-active case lowers the
count instead of returning 0: when nthreads == 1, set aicpu_thread_num_ to 1
only if that satisfies the configured constraint, otherwise reduce it to a valid
value; never assign MAX_AICPU_THREADS, particularly when aic_count_ == 0.
Preserve a positive thread count that lets assign_cores_to_threads() complete
without zero-cluster division or empty core registration.
🧹 Nitpick comments (3)
tests/ut/cpp/CMakeLists.txt (1)

670-697: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate boilerplate from add_a2a3_hbg_runtime_test.

Lines 674-697 repeat the include directories, link libraries, and test-registration code from add_a2a3_hbg_runtime_test (lines 122-147). Only the extra compiled sources differ: pto_shared_memory.cpp here versus scope_stats_collector_aicpu.cpp in the function.

Generalize the function to accept an extra-sources list. This removes the duplicate block and keeps future host-build-graph test targets consistent.

♻️ Proposed refactor
-function(add_a2a3_hbg_runtime_test name src)
+function(add_a2a3_hbg_runtime_test name src)
+    set(extra_srcs ${ARGN})
     add_executable(${name}
         ${src}
         ${CMAKE_SOURCE_DIR}/stubs/test_stubs.cpp
-        ${CMAKE_SOURCE_DIR}/../../../src/common/platform/shared/aicpu/scope_stats_collector_aicpu.cpp
+        ${extra_srcs}
     )
     ...
 endfunction()

Then define the new test as:

add_a2a3_hbg_runtime_test(test_hbg_shared_memory
    a2a3/test_hbg_shared_memory.cpp
    ${CMAKE_SOURCE_DIR}/../../../src/a2a3/runtime/host_build_graph/runtime/shared/pto_shared_memory.cpp
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ut/cpp/CMakeLists.txt` around lines 670 - 697, Update
add_a2a3_hbg_runtime_test to accept and append an extra-sources list when
creating the executable, while retaining its existing include directories, link
libraries, test registration, and labels. Replace the standalone
test_hbg_shared_memory target block with an add_a2a3_hbg_runtime_test call
passing its test source and pto_shared_memory.cpp.
src/a2a3/runtime/host_build_graph/runtime/orchestrator_core/pto_orchestrator.cpp (1)

1064-1079: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Correct the watermark comment.

The code is right. The explanation is not. update_completed_watermark walks forward with is_completion_flag_set(next), so a later device completer that lands exactly on the frontier does walk past this pre-set flag. The precise reason for the explicit host call is narrower: only a completer whose local_id equals the current watermark advances it, so if the frontier already sits at this task's id, no other completer will ever call with that id.

📝 Proposed comment fix
-        // every consumer register_wakes on a producer that never runs on device and
-        // the run hangs. update_completed_watermark only advances when called with
-        // local_id equal to the current watermark, so this task's own call is the
-        // only chance to move the watermark past it — a later on-device completer
-        // whose local_id no longer matches the (still-stuck) watermark will no-op,
-        // not walk past this pre-set flag on our behalf.
+        // every consumer register_wakes on a producer that never runs on device and
+        // the run hangs. update_completed_watermark advances only when its local_id
+        // equals the current watermark. No device thread ever calls it with THIS
+        // task's local_id, so if the frontier already sits at this id, only this
+        // call can move it forward. (A later device completer that does land on the
+        // frontier walks over this pre-set flag as part of its prefix walk.)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/a2a3/runtime/host_build_graph/runtime/orchestrator_core/pto_orchestrator.cpp`
around lines 1064 - 1079, Update the multi-line comment block preceding the
set_completion_flag and update_completed_watermark calls to correct the
explanation of watermark advancement behavior. Replace the incorrect statement
that a later device completer will no-op and not walk past the pre-set flag with
the accurate explanation that update_completed_watermark walks forward using
is_completion_flag_set, so device completers landing on the frontier do walk
past pre-set flags. Clarify the narrower and actual reason for the explicit host
call: only a completer whose local_id equals the current watermark advances it,
so if the frontier already sits at this task's local_id (done_local), no other
completer will ever call with that matching id to move the watermark forward.
src/a2a3/runtime/host_build_graph/runtime/scheduler/pto_scheduler.h (1)

474-515: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Replace realloc with arena or fixed-storage for the failed-completion heap.

FailedCompletionFlagHeap is allocated from the AICPU scheduler state but still calls stdlibc realloc/free and aborts on allocation failure. Since this heap is only needed in rare completion-flag CAS contention, use a small fixed capacity or the scheduler arena instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/a2a3/runtime/host_build_graph/runtime/scheduler/pto_scheduler.h` around
lines 474 - 515, Update FailedCompletionFlagHeap to avoid stdlibc realloc/free
and allocation-failure aborts by using fixed-capacity storage or allocation from
the scheduler arena. Preserve push/pop heap behavior and ensure destroy performs
only the corresponding non-stdlib cleanup, with capacity sized for the rare
completion-flag contention use case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/a2a3/runtime/host_build_graph/runtime/shared/pto_shared_memory.cpp`:
- Around line 153-160: Update the task_window_sizes validation loop in the
shared-memory initialization path to reject zero values before calling
__builtin_ctzll and reject any non-power-of-two value. Keep the existing
shuffle_lower_bits constraint, ensuring every accepted size is a nonzero power
of two with sufficient trailing zero bits before task_window_size is assigned.

---

Outside diff comments:
In `@src/a2a3/runtime/host_build_graph/runtime/scheduler/scheduler_cold_path.cpp`:
- Around line 699-712: Update the scheduler-thread count calculation that feeds
SchedulerContext::assign_cores_to_threads() so the all-active case lowers the
count instead of returning 0: when nthreads == 1, set aicpu_thread_num_ to 1
only if that satisfies the configured constraint, otherwise reduce it to a valid
value; never assign MAX_AICPU_THREADS, particularly when aic_count_ == 0.
Preserve a positive thread count that lets assign_cores_to_threads() complete
without zero-cluster division or empty core registration.

---

Nitpick comments:
In
`@src/a2a3/runtime/host_build_graph/runtime/orchestrator_core/pto_orchestrator.cpp`:
- Around line 1064-1079: Update the multi-line comment block preceding the
set_completion_flag and update_completed_watermark calls to correct the
explanation of watermark advancement behavior. Replace the incorrect statement
that a later device completer will no-op and not walk past the pre-set flag with
the accurate explanation that update_completed_watermark walks forward using
is_completion_flag_set, so device completers landing on the frontier do walk
past pre-set flags. Clarify the narrower and actual reason for the explicit host
call: only a completer whose local_id equals the current watermark advances it,
so if the frontier already sits at this task's local_id (done_local), no other
completer will ever call with that matching id to move the watermark forward.

In `@src/a2a3/runtime/host_build_graph/runtime/scheduler/pto_scheduler.h`:
- Around line 474-515: Update FailedCompletionFlagHeap to avoid stdlibc
realloc/free and allocation-failure aborts by using fixed-capacity storage or
allocation from the scheduler arena. Preserve push/pop heap behavior and ensure
destroy performs only the corresponding non-stdlib cleanup, with capacity sized
for the rare completion-flag contention use case.

In `@tests/ut/cpp/CMakeLists.txt`:
- Around line 670-697: Update add_a2a3_hbg_runtime_test to accept and append an
extra-sources list when creating the executable, while retaining its existing
include directories, link libraries, test registration, and labels. Replace the
standalone test_hbg_shared_memory target block with an add_a2a3_hbg_runtime_test
call passing its test source and pto_shared_memory.cpp.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 44dad46c-6e3c-47e9-97aa-96c14a2fab67

📥 Commits

Reviewing files that changed from the base of the PR and between f2a20ca and 7eb4bda.

📒 Files selected for processing (16)
  • src/a2a3/runtime/host_build_graph/aicpu/aicpu_executor.cpp
  • src/a2a3/runtime/host_build_graph/docs/RUNTIME_LOGIC.md
  • src/a2a3/runtime/host_build_graph/runtime/orchestrator_core/pto_orchestrator.cpp
  • src/a2a3/runtime/host_build_graph/runtime/orchestrator_core/pto_runtime2.cpp
  • src/a2a3/runtime/host_build_graph/runtime/pto_async_wait.h
  • src/a2a3/runtime/host_build_graph/runtime/pto_runtime2_types.h
  • src/a2a3/runtime/host_build_graph/runtime/pto_shared_memory.h
  • src/a2a3/runtime/host_build_graph/runtime/scheduler/pto_scheduler.h
  • src/a2a3/runtime/host_build_graph/runtime/scheduler/scheduler_cold_path.cpp
  • src/a2a3/runtime/host_build_graph/runtime/scheduler/scheduler_completion.cpp
  • src/a2a3/runtime/host_build_graph/runtime/scheduler/scheduler_context.h
  • src/a2a3/runtime/host_build_graph/runtime/scheduler/scheduler_dispatch.cpp
  • src/a2a3/runtime/host_build_graph/runtime/shared/pto_runtime2_init.cpp
  • src/a2a3/runtime/host_build_graph/runtime/shared/pto_shared_memory.cpp
  • tests/ut/cpp/CMakeLists.txt
  • tests/ut/cpp/a2a3/test_hbg_shared_memory.cpp
💤 Files with no reviewable changes (1)
  • src/a2a3/runtime/host_build_graph/runtime/scheduler/scheduler_context.h

…ulers

Co-authored-by: noabauma <noah.baumann@h-partners.com>

Co-authored-by: Sergio Martin <eienburuu@gmail.com>
@ChaoZheng109

Copy link
Copy Markdown
Collaborator

Thanks for putting both variants up side by side — having #1618 and #1619 as an explicit either/or made this much easier to reason about.

Two separate topics below: the forward-looking functional groundwork, and the performance case.


1. Functional groundwork (flag-slot reuse) — the premise does not hold in hbg

The int32 completion stamps, the slot-reuse gate, the failsafe min-heap, and cached_completed_watermark all exist to support flag-slot reuse. Reuse only becomes useful once the ring actually wraps, and that cannot happen in host_build_graph today — not because reclaim is unimplemented, but because of the execution model:

hbg's orchestrator and scheduler are strictly serial, not overlapped.

host:   run_host_orchestration()   <- the entire graph is built here
host:   copy_to_device()           <- the populated SM is uploaded
--------------------------------------------------------------------
device: AICPU kernel boots scheduler-only
        ("host_build_graph has no device-side orchestrator", aicpu_executor.cpp)

By the time any scheduler thread exists, the host has already finished submitting. So even with a complete reclaim protocol in place, the slots freed by reclaim would have no consumer — nobody is waiting to submit into them. Reclaim pays off only when the host wants to submit task N while the window is full, i.e. when O and S overlap in time. That is the tensormap_and_ringbuffer model, not hbg's.

Consistent with that, last_task_alive never advances here:

// pto_scheduler.h:26
// reclaim (whole-graph-resident), so last_task_alive is not advanced here.

which pins the ring allocator's gate at local_task_id_ + 1 < window_size_. We hit that live while benchmarking — paged_attention Case1 (~65792 tasks) on the default 16384 window:

[TaskAllocator] BLOCKED: tasks=16383/16384, on=task, spins=350720000
Assertion failed: index < output_count_   (pto_types.h:114)

(It runs fine with PTO2_RING_TASK_WINDOW=131072 PTO2_RING_HEAP=805306368.)

One more thing worth noting for whenever reclaim is taken up: completion_flags is one of four parallel arrays indexed by local_id & task_window_mask — alongside task_descriptors, task_payloads, and slot_states. Wrapping aliases all four. The other three hold live wake lists, fanin arrays other tasks are still reading, and in-flight descriptors, so they need a real reclaim protocol rather than an id stamp. Landing the flags piece on its own does not move the task-count ceiling.

So as things stand, none of this machinery is exercised on hbg: the reuse gate never fails, the failsafe heap stays empty, and cached_completed_watermark only mitigates a watermark fallback that the reuse itself introduced.


2. Performance — we measure this PR at parity with its base

We benchmarked the PR head against its own merge-base on one card.

Results (a2a3, aicpu_thread_num=4, device wall-clock, 100 rounds/side)

workload base (merge-base) #1618 Δ
qwen3_14b_decode StressBatch16Seq3500 36.550 ms 36.331 ms −0.60%
paged_attention Case1 24.816 ms 25.328 ms +2.06% (within noise)

bgemm / matmul / vector_example were also run, but at 0.10–0.19 ms their measured cv is 5–12%, so they carry no resolution at this scale and we are not quoting them.

Method

  • Baseline = this PR's merge-base 80aa2876; current = a3a637f0. The only difference between the two trees is this PR's single commit.
  • Two detached-HEAD worktrees, each with its own venv and pip install --no-build-isolation -e ., so the nanobind extension and the runtime binaries are fully isolated per side.
  • Same physical card: the whole comparison runs inside a single task-submit invocation, so one device lock covers both sides; baseline and PR run back-to-back per workload.
  • 100 rounds per side, round 0 dropped as warm-up, outliers trimmed, then the mean.
  • Metric is the runner_run.device_wall span from the [STRACE] markers, rendered by python -m simpler_setup.tools.strace_timing <log> --rounds-table.
  • On pa, round times fall into two groups (~24 ms and ~51 ms, roughly half each, on both sides); the figure quoted above is the fast group. We are looking into the cause separately.
  • paged_attention Case1 needs PTO2_RING_TASK_WINDOW=131072 PTO2_RING_HEAP=805306368 to run at all (see §1).
  • qwen3_14b_decode ships as runtime="tensormap_and_ringbuffer"; we re-pointed it to "host_build_graph" — a one-line change, verified byte-identical in both worktrees.

Cross-check against #1544

Our base (which contains 3S+1P) measures 24.816 ms on pa Case1. The 3S+1P number posted in #1544 is 25.013 ms — 0.8% apart, from an independent measurement on a different device on a different day.

Taking #1544's 4S baseline (28.016 ms) as the reference for a plain revert:

plain 4S revert (no optimisations)   28.016 ms   [#1544]
#1618 (4S + shuffle + frontier gate) 25.328 ms   [this measurement]
3S+1P base                           24.816 ms   [this measurement]

So the bit-shuffle and the frontier-gated watermark clearly do real work — they recover roughly 9.6 of the 10.7 points a plain revert would cost (that comparison is cross-experiment — different day, device, and 4S baseline build — so treat the figure as indicative). But against the current base they land at parity, not ahead.

What we would like to reconcile

Our numbers do not line up with the improvement the charts in the PR body appear to show, and we cannot read the underlying values off the images. Could you post the numbers as text, along with your measurement method and what the baseline is — main, or #1619? Since both PRs revert 3S+1P, a #1618-vs-#1619 delta measures only eager-vs-lazy watermark maintenance, which is a much narrower claim than #1618-vs-main.


3. 3S+1P is not only about ready-queue contention

One consideration behind the 3S+1P split that we would like to see represented in the comparison: the dedicated P thread does more than remove multi-producer contention. It also takes async deferred-completion polling and dummy / predicate-failed retirement off the dispatch path entirely. From main's own comment, which this PR removes:

Async deferred-completion polling and dependency-only (dummy / predicate-failed) retirement both run on P, which owns every completion→ready transition — the scheduler threads' loop stays purely core-local (poll own COND, dispatch own cores) and never touches the shared mailbox or dummy queue.

and, for the dummy queue specifically:

P produces and drains it, so the queue is single-threaded end to end.

Under 4S both come back onto every scheduler thread's loop: async_wait_list.poll_and_complete behind a shared try_lock(), and dummy_ready_queue becomes a contended MPMC queue drained by all four threads. For graphs with substantial async or dummy/barrier traffic, that is work now sitting on the dispatch path of every thread rather than on a thread that owns no cores. We expect 3S+1P to hold a substantial advantage on those workloads that 4S cannot match, and that class is where the two designs should really be separated.


To be clear on where we stand: §1 and §3 are observations on the design, and §2 is a request for data rather than an objection. If the numbers show #1618 ahead of main under a method we can reproduce, that settles it.

@raphael-s-steiner

Copy link
Copy Markdown
Contributor Author

1. Functional groundwork (flag-slot reuse) — the premise does not hold in hbg

There are two options here:

  • The number of tasks is always less than (or equal to) the number of flags.
  • The number of tasks may be larger the number of flags.

In the first case, there is no need for the mask in local_id & task_window_mask. Furthermore, I would also question the need for the watermark in the first place? As this would remove the cache-line contention.

In the second case, reuse of flags is necessary. This also removes the necessity to increase PTO2_RING_TASK_WINDOW (at least from the perspective of the completion flags).

If the first case is truely the design choice, then a lot of the PR can be stripped.

@raphael-s-steiner

Copy link
Copy Markdown
Contributor Author

3. 3S+1P is not only about ready-queue contention

I believe it all comes down to a proper benchmark for this case.
In PyPTO 2, this is something we investigated and we have found the following:

  • The benefit of the single threaded queue does not outweigh the latency added by moving task responsibilities through SPSC queues to another thread.
  • Having 4 instead of 3 scheduling threads drastically improves reaction time to changes in the AICores and increases their uptime.
  • Due to batching, the MPMC queues are not that expensive.

I suggest the different designs 3+1, 4, and 4+1, should all be investigated on a proper benchmark.

@ChaoZheng109

Copy link
Copy Markdown
Collaborator

Thanks — that is exactly the right way to frame it, and the answer is case 1.

Case 1 is not a preference, it is enforced today

The ring allocator gate is local_task_id_ - last_alive + 1 < window_size_, and last_task_alive never advances in hbg (pto_scheduler.h:26), so it reduces to local_task_id_ + 1 < window_size_. Task count is therefore hard-capped at window - 1, while completion_flags is window entries long. tasks <= flags holds by construction — case 2 is not something the runtime can currently reach, and hitting the cap is a hard failure, not a wrap:

[TaskAllocator] BLOCKED: tasks=16383/16384, on=task, spins=350720000

Why the code reads ambiguously here

host_build_graph began as a port of tensormap_and_ringbuffer — the tensormap, the ring buffer, and the orchestrator+scheduler split were all carried over together, deliberately, to keep the two runtimes structurally close. But hbg's defining constraint is different: the orchestrator must run to completion before the scheduler starts, and that is intentional, not a limitation we plan to lift.

The result is that several ring concepts survive in hbg without carrying any meaning:

  • last_task_alive is never advanced, so the ring never wraps;
  • task_window_mask never actually masks anything, because local_id cannot exceed the window;
  • the reclaim machinery exists but has nothing to reclaim on behalf of.

These vestiges are what make the design intent ambiguous when reading the code, and removing them is precisely what we want to do next. So your instinct that a lot can be stripped is right — and it reaches beyond the code this PR touches.

It is also the direction, not just the status quo

The concern behind case 2 is presumably that a large model submits too many tasks — a 40-layer Qwen decode being the obvious example. Our answer to that is not a bigger flag array but a smaller task count: #1444 adds a GRAPH composite task type that records a repeated sub-DAG once and then submits one outer ring task per invocation, with the internal nodes held in AICPU-local descriptor/payload/slot storage that consumes no ring slot at all. A 40-layer decode becomes 40 ring tasks rather than 40×N.

So the intent is to keep task counts comfortably below the flag array, not to make the array circular.

On the two things you would strip

The mask — agreed, under case 1 local_id & task_window_mask is redundant.

The watermark — you are right to question it, and it holds up on inspection. Once reuse is out, its only reader is wait_one_consumers inside wait_for_tensor_ready(..., wait_for_consumers=true), and that path is effectively unreachable in hbg. flush_segment runs wait_one_producer first and returns on failure, so the watermark is only read once the producer's task_state has already reached COMPLETED. A device task cannot reach that during orchestration — O runs to completion before S starts — so those callers time out at the producer wait and never get there. The only producers that do pass are the ones the host completed inline, and for those the watermark check is either trivially satisfied or a timeout in its own right.

So it performs no real gating today. The one thing worth deciding deliberately rather than as a side effect: removing it also removes set_tensor_data's WAR gate, which is public API surface even in its currently non-functional state.

One correction on case 2

This also removes the necessity to increase PTO2_RING_TASK_WINDOW (at least from the perspective of the completion flags).

The parenthetical is doing a lot of work there. task_window_size sizes four parallel arrays — task_descriptors, task_payloads, slot_states, completion_flags — and it also sets the allocator gate above. Flag reuse on its own therefore would not remove the need to size the window to the task count; the other three arrays and the gate still require it. With GRAPH tasks in place, the task count stays low enough that the default window is sufficient anyway.

@ChaoZheng109

Copy link
Copy Markdown
Collaborator

Agreed — a proper benchmark is the right next step, and it is what we intend to focus on next. A shared benchmark is also what we need for performance alignment generally, not just for this decision.

Where the thread budget comes from

Framing this as "3 vs 4 scheduling threads" understates what actually changed.

The AICPU thread count is an architecture-level choice made per platform, for hardware reasons we are happy to go into separately:

  • a2a3: 4 threads, spent in tensormap_and_ringbuffer as 1 orchestrator + 3 schedulers — the orchestrator runs on the AICPU and owns no cores;
  • a5: 5 threads, as 1O + 4S.

host_build_graph moves the orchestrator to the host, which frees exactly one thread. On a2a3 we spent that freed thread on a dedicated resolution thread, giving 3S + 1P.

So the number of core-owning threads has been 3 throughout on a2a3. What #1618 does is not "3 → 4 schedulers"; it is spending the thread freed by moving O to the host on a fourth core-owner rather than on a resolver. The real question is therefore how best to spend that freed thread, and that is exactly what a benchmark should answer.

On 4+1

4+1 needs five scheduling threads, so it is not measurable on a2a3, where the budget is four — there the comparison is 3S+1P vs 4S. a5's budget is five, but how it gets spent is a separate discussion from this PR.

On the MPMC point

That is plausible and we do not dispute it — batching amortises the enqueue cost, and our own dispatch path already pops in batches sized to the thread's free cores. If MPMC contention is not the dominant term, then the case for a dedicated resolver rests on the other half of what P does, which is where we would want the benchmark to look.

Where the data currently stands

From our measurements: on ordinary workloads, 3S+1P and #1618's 4S are at parity — qwen and paged_attention both land within noise. So on the cases we can measure today, the two designs are equivalent, and the decision cannot be made on those numbers alone.

Our expectation — and it is an expectation, not a measurement — is that the separation shows up on async-heavy graphs, where P absorbs the mailbox polling and dependency-only retirement that otherwise sit on every scheduler thread's dispatch loop. No current hbg test exercises async at all, so that regime is simply unmeasured today.

Next step

We will drive building the benchmark set. To be useful it needs to span both ends — scheduler-throughput-bound and compute-bound — and add async/dummy-heavy cases, which is the gap.

@ChaoZheng109

Copy link
Copy Markdown
Collaborator

@noabauma — flagging you since you own the performance testing.

Thanks for the charts — having the ladder made this straightforward to reproduce. We ran the same ladder against this PR's merge-base and cannot reproduce the improvement: we measure the two branches at parity on every rung.

Our results — device_wall, base (80aa287) vs #1618 (a3a637f)

workload tasks base cv n #1618 cv n Δ
bgemm 128 137.2 µs 3.3% 999 137.4 µs 3.4% 999 +0.11%
Mid4 1,028 590.7 µs 1.7% 299 589.5 µs 1.6% 299 −0.21%
Mid16 4,112 1610.3 µs 1.3% 249 1606.6 µs 1.2% 249 −0.23%
Mid64 16,448 5813.7 µs 1.0% 149 5808.6 µs 1.1% 149 −0.09%
Case2 32,832 11935.8 µs 2.3% 199 11958.9 µs 2.5% 199 +0.19%
Case1 65,792 24426.2 µs 9.5% 149 24490.6 µs 9.3% 149 +0.26%

Every rung is within ±0.3%, against cv of 1.0–9.5%.

Method

  • base = this PR's merge-base 80aa2876, pr = a3a637f0; the only difference between the two trees is this PR's single commit.
  • Two worktrees with isolated venvs. We verified the two sides really are different builds: the libaicpu_kernel.so binaries differ, base contains run_resolution_thread and no cached_completed_watermark, pr the reverse.
  • Cases from Add: host_build_graph copies of the paged_attention examples #1667's examples/a2a3/host_build_graph/paged_attention, unmodified — Case1 and Case2 keep its runtime_env verbatim (2 GB and 1 GB heap). Mid4 / Mid16 / Mid64 reconstructed as batch 4 / 16 / 64 with Case1's other parameters; the resulting task counts (1,028 / 4,112 / 16,448) match your ladder. bgemm from tests/st/a2a3/host_build_graph/bgemm.
  • One device, one lock: all twelve runs execute inside a single task-submit invocation on device 8, so both branches share the same card for the whole comparison, and base / pr run back-to-back within each workload.
  • Rounds matched to your reported n. Round 0 dropped as warm-up. Metric is the runner_run.device_wall span from the [STRACE] markers.

Side by side

workload our base your main our #1618 your eager our Δ your Δ
bgemm 137.2 101 137.4 91 +0.11% −9.9%
Mid4 590.7 556 589.5 495 −0.21% −11.0%
Mid16 1610.3 1576 1606.6 1392 −0.23% −11.7%
Mid64 5813.7 5755 5808.6 5274 −0.09% −8.4%
Case2 11935.8 13605 11958.9 11133 +0.19% −18.2%
Case1 24426.2 31349 24490.6 22132 +0.26% −29.4%

Our base tracks your main closely on Mid4 / Mid16 / Mid64 (within 1–6%), which suggests the cases, the ring configuration and the platform are aligned. The divergence is concentrated on the two largest rungs.

Where your figures fall inside our distributions:

workload our base range your main our #1618 range your eager
bgemm 127 – 154 101 (below min) 124 – 168 91 (below min)
Mid4 560 – 632 556 (below min) 565 – 619 495 (below min)
Mid16 1557 – 1657 1576 (inside) 1547 – 1662 1392 (below min)
Mid64 5749 – 6487 5755 (inside) 5736 – 6500 5274 (below min)
Case2 11746 – 13511 13605 (above max) 11757 – 13105 11133 (below min)
Case1 22945 – 30909 31349 (above max) 23026 – 31173 22132 (below min)

What we think could produce this

1. The runs may not have been interleaved. We ran base and pr back-to-back within each workload, under one lock, so any drift over time hits both branches equally. Measuring three branches as three separate sessions would map any drift between sessions — thermal state, other users coming and going, neighbouring die activity — directly onto the branch axis.

2. Card selection. The chart says dev 3-7, which is five devices of mixed parity. We measured this effect directly: on an odd device whose neighbouring die was busy, Case1 split into two modes near 23 ms and 51 ms; on an exclusively held even device the second mode disappeared entirely, with the maximum at 30.9 ms. It also matches which rungs you flagged [BIMODAL] — bgemm and Mid4 unimodal, Mid16 / Case2 / Case1 bimodal. That ordering is by run duration, which is what neighbour interference looks like: the longer a workload occupies the card, the more likely it overlaps someone else's work.

3. A genuine machine difference — but the pattern argues against it. A machine or environment difference should shift both branches the same way. It does not. Your eager sits below the minimum of our #1618 distribution on all six rungs (7–34% faster), while your main matches our base within 1–6% on Mid4 / Mid16 / Mid64 and sits above the maximum of our base on Case2 and Case1. The two branches relate to our data by different rules, and a uniform machine offset cannot produce that.

Could you describe your setup in more detail?

Two notes on our side first, since both are places where we may already have diverged from you:

Beyond that: to work out where the difference comes from, could you describe your measurement in detail — the environment and how the runs were organised (branch builds, CANN version, which device(s) and whether they were held exclusively, and how the three branches were ordered relative to one another) — and the data collection point: which markers or which tool produce the "whole-device wall clock" and the "AICore task execution time" spans.

With that we can align our runs to yours and narrow down what produces the gap, rather than continuing to measure separately. Happy to re-run anything under a configuration you specify.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants